Skip to content

Send somebody a coworker, and grant it nothing on arrival - #299

Draft
jerelvelarde wants to merge 26 commits into
CopilotKit:mainfrom
jerelvelarde:feat/bot-templates
Draft

Send somebody a coworker, and grant it nothing on arrival#299
jerelvelarde wants to merge 26 commits into
CopilotKit:mainfrom
jerelvelarde:feat/bot-templates

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Import a coworker

The catalogue, and the ceiling an import applied

The problem

A coworker is the most valuable thing a person builds here, and it cannot leave the deployment it was
built in. The only way to give a colleague the Bot you spent a week on is to describe it and have
them do the week again, and across a fleet the same Bot is rebuilt by hand in staging and in
production until the two drift.

Two things almost do this and neither fits. The tenant package is the whole deployment — brand,
channels, model, roster — read from a directory at boot by whoever runs the process; it cannot
describe one coworker, and it is unsafe on a stranger's file, since expandEnvironment substitutes
${NAME} out of the server's own environment before the YAML is parsed. duplicate copies four
presentation fields, drops the endpoint and every grant, and throws ManagedAgentUnavailableError
outright when the deployment has no Bot of its own — so on the one-container image the README
recommends, that button is dead.

The design is already written down in the code that needs it: tenant-package.ts:137 concludes a
skill is safe to seed because "a skill is an instruction and confers nothing", while an MCP grant is
not because "those reach a person's own account". Nothing used it.

The approach

A Bot template is one YAML file describing one coworker — identity and prose, the skills it
depends on, the capabilities it asks for, and a ceiling on what it may do. The vocabulary is the
tenant package's, so anyone who has read examples/fintech/ can read a template.

Configuration travels; capability does not. No id, endpoint URL, credential, MCP grant, component
source or policy rule can travel, because none are fields: a document carrying one fails to parse and
is told which key and why. That beats stripping them silently — an author who tried to ship a key
learns, and a reviewer is not reading a redacted copy of something larger. Import writes exactly one
grant, plugin_grants kind 'skill', marked template:<digest12> so a retraction takes back what
the import gave and never an administrator's own. Everything else lands in a ledger as requested and
not granted, satisfied afterwards on the screens that already decide those things.

Three divergences from the package loader, each a security decision. Parsing is strict, because
an ignored key is one the reviewer's eye slid over and the parser agreed to. There is no environment
interpolation at all, and the check reads raw bytes so a comment cannot carry one either. Slugs use
the API's rule rather than the package's looser one, which admits x and find- — both install
cleanly and are then permanently uneditable through the product.

The consent screen is the control, so it is built as one. The role description and every skill's
instructions render verbatim and unabridged, under the sentence that a stranger wrote them. Invisible
codepoints are a parse refusal rather than a rendering problem, because a review control that can be
made invisible is not a control.

An unmet ask never blocks the install, which is the decision most likely to be questioned.
Blocking would make "grant everything" the fastest route to a working Bot. The Bot arrives cold and
does not lie about it: grantedToolGuidance builds its self-description from the tools it was
actually offered, so it says it has no source rather than claiming what the template promised.

The ceiling is enforced, not just disclosed. The boundary: block compiles into CEL deny clauses
scoped to one Bot. The Bot is the LEADING conjunct because cel-js short-circuits, and an unscoped
clause that throws would refuse every action for every Bot rather than the one named. Host lists
compile to equality, never a pattern, because matches throws on a bad pattern and a throwing deny
counts as a match. Every clause is evaluated against neutral contexts — through the engine that will
judge it — before it is stored.

They live in template_boundaries, composed into what the engine reads and never written into
action_policy.deny: that array is replaced wholesale from a screen snapshot with no version column,
so a clause stored there would be erased by the next unrelated save. The screen is served what an
administrator wrote, with the generated clauses beside it, read-only.

A catalogue, and sources pinned to a commit. The image ships templates and reads them per file,
so one malformed file names itself in the log instead of stopping the deployment booting. Beyond that
an administrator may register a repository pinned to a sha, allowlisted by an environment variable
that ships empty and that no screen can widen, fetched server-side so no browser acquires a
third-party origin. Moving the pin is the only update mechanism.

What is not covered

  • No hosted registry, and no share link. Sharing is a file, an in-box directory, or a repository
    an administrator pinned. A link that installs for a stranger would need a service somebody operates,
    a moderation queue and a signing key; the reference catalogue is
    jerelvelarde/awesome-openbot-templates,
    and its trust model is git's — whoever can merge there.
  • A source's trust is its pin. No signing and no publisher verification. author is a claim,
    rendered as one. Pin a commit you have read.
  • No update channel. template.version is a string nothing reads. Security fixes do not
    propagate either — that is the trade, and auto-update has no safe form without publisher identity.
  • type: built_in cannot travel, so exporting a shipped fintech Bot is not a faithful round trip.
  • Components are requested, never withheld, and declining an ask does not revoke a grant already
    made — revocation goes through the existing screens, because the ledger stores the ask and a
    decision, never a second source of truth for a permission.

Verification

format:check, lint and typecheck clean. bun run test:ci reports 2318 pass, 0 fail against
a baseline of 2149 on main — 169 new tests.

Driven end to end against a running deployment throughout, which is how several of these were found:
the routes were mounted behind an optional dependency the composition root never built, so
/api/templates answered 404 on a real server while every route test passed; the packer's own
secret-scanner fixtures were shaped enough like credentials to be blocked by push protection; and the
boundary screen was served the composed policy, so a clause an import applied appeared in the list an
administrator edits, beside a Remove that saving filters back out. The test covering that last one
asserted the composed value, so it agreed with the bug rather than catching it.

Three more came from CI failing where a laptop could not, all of them the same shape: bun test runs
every file in one process, parts of the app decide at module scope whether they have a browser, and
whichever file loads the app graph first decides that for everybody. Six integration files held their
connection pool open; Happy DOM registers at about:blank, whose origin is the string "null", so
the first file to reach @/lib/auth/client threw while being imported; and auth-client.test.ts
assigns a window with a location and nothing else, which left window.history undefined for
app/src/router.tsx. Two of those fixes touch files this feature does not otherwise go near; they are
here because these tests are what made them reachable.

File What it holds down
shared/bot-template.test.ts Every refusal by reason code, invisible codepoints by Unicode property class, digest stability across key order and Unicode form
server/tests/template-pack.test.ts Every strip, and each secret shape refused with benign near-misses accepted
server/tests/template-install.integration.test.ts One transaction, no MCP grant written anywhere, the digest 409, retraction leaving a hand-made grant intact
server/tests/template-routes.integration.test.ts Each authz boundary, and that an ask recorded unavailable cannot be granted even after the connector appears
server/tests/template-boundary.test.ts Every vocabulary value, equality-only host lists, and a clause that would throw being refused
server/tests/template-boundary-enforcement.integration.test.ts The clause enforced against its Bot and no other, surviving an unrelated policy save, and lifted by retraction
server/tests/template-catalogue.test.ts A malformed file skipped and named; a repository not on the allowlist, and a branch instead of a sha, both refused
server/tests/template-gallery-routes.integration.test.ts A gallery install read from the catalogue rather than the posted body, and the author's sentence and clause on the admin payload
app/tests/import-template.test.tsx That nothing model-visible is truncated, and that a key typed for one template is not carried to the next

A hostile review over the diff produced 27 candidate defects; 24 survived independent verification and
are fixed in the last five commits — including one where an ask both consent surfaces described as
inert sat beside a button that wrote a live MCP grant.

…tes never mount

The routes were wired into createApp behind an optional dependency and the composition root never
constructed one, so /api/templates answered 404 on a real server while every route test passed: the
tests build the app directly and hand it the dependency the process never had.

Assembled here rather than inside createApp because the installer needs the vault, the plugin store,
the trail and this deployment's endpoint policy, and each of those already exists exactly once in
this file. The endpoint policy is deliberately the same pair agentFetch is given: an address
registered by an import must be held to exactly what an address registered through /api/agents is.
A connector id was checked for length and nothing else, so a template could file a real tool ref as
one. With no tools under it the per-tool check that a ref belongs to its connector never runs, so
`id: google-drive/read_file_content` parsed cleanly and arrived where the server and the profile
screen both re-derive connector-from-tool by looking for a slash.

The invisible-character refusal is now the Unicode property classes rather than an enumerated list,
which had drifted nine blocks narrower than the sentence it defends and let the first sixteen
variation selectors through. Lengths are measured in the units the edit form measures them in, so a
template can no longer install a Bot its owner cannot then save.
The grant route read the ref and never the ledger row's status, so an ask the consent screen had
described as "nothing will be granted and nothing will be written", and the profile as "there is
nothing yet to grant", sat beside a button that wrote a live MCP grant.

Two guards, and the overlap is load-bearing. The status guard refuses an ask that was unavailable
when the person read it, because somebody connecting that server afterwards is not their consent.
The live re-check refuses a ref whose server or tool is absent right now, because the stored status
is a snapshot from resolve time and a grant for a server that does not exist is invisible on every
screen until the day one appears under that id.
The endpoint check and the byte-identical guard behind `reuse` both had no test, so either could
have been deleted and the suite would have stayed green. Two skills in one template can no longer
plan into the same slug, an imported Bot keeps the avatar its template named, and reuse of a skill
somebody else owns falls through to a suffix rather than quietly attaching theirs.
The audit write went to the pool while the caller held a transaction on a pooled connection, so
every import needed two at once and enough concurrent ones would wait on each other.
…be refused

Name and title were truncated on the consent screen and both are concatenated into the standing role
the model is given, so a stranger could write two hundred characters into a field the reviewer saw
forty of. Nothing model-visible is clipped now.

A deployment in dry-run enforces nothing, so it gets the amber warning rather than the reassuring
block. A key typed for one template is no longer carried to the next one's address. The audit page
knows the template events, so a refused import reads as refused rather than as allowed.
bun test runs every file in one process, so a pool left open is held for the rest of the suite. Six
new files opened one and never closed it, and past a certain point PostgreSQL stops handing out
connections — which does not read as a connection limit, it reads as the run dying at whatever file
happened to be next. On CI it died at a different one each time and printed no summary at all.

Every other integration test here already closes. These now do too, after their cleanup rather than
before it.
Happy DOM defaults to about:blank, whose origin is the string "null". Better Auth builds its base
URL from window.location.origin when it is not given one, so the first file to pull in the auth
client under a bare registration threw Invalid base URL: null while it was still being imported,
taking its tests with it and reporting an unhandled error rather than a failure anybody could place.

Locally auth-client.test.ts stubs a window with a real origin and, when it runs first, the client is
already cached by the time a rendered test needs it. That is an ordering accident. On CI the order
differs, eight tests never ran, and the suite reported a count nobody could reconcile.
The sign-in test assigns a window with a location and nothing else. That was harmless while nothing
in the suite installed a DOM; the rendered tests here do, and overwriting a registered window leaves
document defined and window.history undefined. app/src/router.tsx builds its history at module scope
from exactly that pair, so it threw while being imported by whichever file reached it next, and took
that file's tests with it.
…erstands

A template says shell, files, browser, hosts and mcp in a closed vocabulary; this turns that into
CEL the gateway evaluates like any other rule. A template never writes CEL, and could not: the
vocabulary has no way to say anything but less.

Three properties, each with a reason. The Bot is the LEADING conjunct because cel-js short-circuits
and an unscoped clause that throws refuses every action for every Bot rather than for the one named.
Host lists compile to equality and never to a pattern, because the matches helper throws on a bad
pattern and a throwing deny counts as a match. And every clause is evaluated against ten neutral
contexts before it is stored, through the same engine that will judge it, so a malformed one is
refused rather than written.

The emitter refuses a value it cannot write verbatim rather than escaping it: cel-js does not decode
escape sequences, so an escaped literal would be a rule that silently never matches the host it names.
The clauses are written in the same transaction that creates the Bot, and composed into the policy
the gateway reads rather than into the array an administrator edits.

Never into action_policy.deny, and the reason is a lost update nobody would see: that array is
replaced wholesale from a screen snapshot with no version column, so a clause stored there is erased
the next time somebody saves an unrelated rule. Separate storage makes that impossible. Retracting an
import lifts its ceiling the same way it takes back its grants.
The consent screen said a deployment does not yet enforce what a template declares. That was true and
is not any more, so it says what it now does instead. The separate warning about a deployment whose
policy still allows every action stays: a ceiling only ever subtracts, and a Bot held to browsing on
a deployment that permits everything is still a Bot that can browse.

Boundaries lists the generated clauses in a group of their own, grouped by the coworker, and offers
no way to edit them there — they are not in the array that screen posts.
…nned to

Two sources, and nothing is fetched from the network unless an administrator registers one.

The directory in the image is read per file rather than as a package. The tenant loader refuses to
boot on a malformed file, which is right for an operator's own configuration and wrong for a
directory of many authors' work: one bad file names itself in the log and the rest still load.

A git source is pinned to a commit, allowlisted by an environment variable that ships empty and that
no screen can widen, and fetched server-side so the browser never acquires a third-party origin and
the source never sees an end user's address. Moving the pin is the only update mechanism, it is an
administrator's deliberate act, and it changes nothing already installed.
…wser

Installing from the gallery re-reads the template server-side and refuses a digest that no longer
names that entry, so what is installed is what the deployment holds rather than what was posted back
to it. There is no way to publish into the gallery over HTTP: publishing is a git push.
The gallery lists what this deployment offers with the author rendered as a claim and no counts and
no ratings — there is nothing to count and a count is a thing to forge. Admin gains a page listing
every import, what it asked for, the ceiling it applied, and Retract.
bun test runs every file in one process, and parts of the component library decide at module scope
whether they have a browser at all — a portal that concludes it has none stays switched off for the
whole run. This file loads the app's entire route tree, so whichever order the suite was walked in,
it decided that for everybody: the admin dialog tests failed here while the dialog opened correctly
in a browser.

The import is dynamic so the registration stays in front of it. The file already needed browser
globals; it was getting them by accident from whichever rendering test happened to run first.
A values file written before this key existed must still render, and the chart proves that by
rendering without each key added since v0.0.5. Emitted unconditionally it produced an environment
variable with an empty value, which is not the same as an absent one: the server's own default
resolves to this same directory, and an empty string is a deployment told to read templates from
nowhere.
The boundary route answered with the composed policy, so a clause an import applied appeared in the
list the screen edits, with a Remove button beside it. Saving filters those clauses back out, so the
button succeeded, changed nothing, and left the rule enforced with nothing on screen saying why. A
screen that offers an action it will not take is worse than one that does not offer it.

Two readers now: get is what the engine enforces, authored is what a person wrote. The clauses are
still on the same page, in the read-only group that explains where they came from.

The test that covered this asserted the composed value, so it agreed with the bug rather than
catching it. It now asserts the editable list is the operator's own.
Every template drew the same box icon, so a dozen of them were a dozen identical rows and the eye had
nothing to land on. A card now carries the drawing the coworker will actually have — the same seed,
so the card previews what importing produces — over a wash taken from that seed, which makes one
template distinguishable from another before either name is legible.

A grid and a wider shell, which is the one place this feature leaves the layout every other screen
uses, and the reason is the same one the audit log has: this is the only browse surface in the
product. Somebody is comparing coworkers they have never seen against each other, and at prose width
that comparison is a scroll.

WHAT WAS NOT TAKEN from the reference. No install count, no downloads, no stars, no rating, no
featured badge, no price. They are most of what gives a marketplace its density and this one cannot
draw a single one honestly: nothing here counts anything, there is no service to count on, and a
number supplied by the author of the thing being judged is worse than no number. The space they would
have taken goes to the claim, the summary, and what the template asks for.

The card also survives a server that has not rolled yet: an absent avatar seed falls back to the slug
rather than taking the page down, which is what reading .length off undefined did.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant